iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

本篇階段:Prj#5 股票分析

使用介面:Claude Code(via VS Code)


前情

前兩天把總經那一層做完了:二十四條序列、兩層評分、一堆「算不出來就說算不出來」的處置。今天換一個層級,看大盤跟追蹤大盤的 ETF。

這一天的主軸從一個數字開始,雖然我沒有賺到,近一年的報酬:

  • 加權指數 ^TWII +89.8%
  • 元大台灣 50 0050.TW +103.4%
  • 富邦台 50 006208.TW +98.9%

第一組差 13.6 個百分點,第二組差 4.5 個百分點;而美股那邊,^GSPC +17.8%、SPY +18.4%、VOO +18.4%,差 0.6 個百分點

同樣是「指數與追蹤它的 ETF」,兩個市場的差距仍相差超過 20 倍(22.7 倍)。今天會花篇幅解釋,並且把五個技術面維度的算式展開來說明與討論、然後是籌碼面的相關問題。


重要說明:本文所有數字與敘述僅為個人技術實作紀錄,不構成投資建議


1. 必須先講的近似

今天這張表上有三種東西,性質完全不同,先分清楚:

是什麼 能不能買賣
^TWII 加權指數 一個計算出來的數字,涵蓋整個上市市場八百多檔 不能
臺灣 50 指數 另一個計算出來的數字,只涵蓋五十檔 不能
0050006208 追蹤臺灣 50 指數的 ETF,是真正在市場上換手的標的

0050 追蹤的是臺灣 50 指數,不是加權指數。

而 yfinance 抓不到臺灣 50 指數,^TAIEX^TW50 都試過,所以拿 ^TWII 當對照本身就是一個近似。0050 追的那個籃子,跟加權指數不是同一個籃子。


2. 為什麼台股差 13.6 個百分點、美股差 0.6

2.1 兩組比較不是同一種比較

加權指數涵蓋整個上市市場八百多檔,臺灣 50 指數只有五十檔,而且按市值加權,前幾大權值股的權重極高。所以 0050 重壓在少數幾檔上,加權指數則被幾百檔中小型股稀釋。13.6 個百分點就是這件事的量化版本。

美股那邊 SPYVOO 追的就是 ^GSPC 本身,同一個籃子,所以差距只剩追蹤誤差與費用,0.6 個百分點。這兩組不是同一種比較。 台股那組比的是「兩個不同的籃子」,美股那組比的是「同一個籃子的兩種包裝」。

順帶把「類群」這件事講掉:0050 是市值加權的籃子,不是產業籃子。它重壓半導體是市值造成的結果,不是選股邏輯。這套東西沒有做產業別分類,理由是這條管線的來源裡沒有一份穩定的產業別欄位:證交所的產業分類與 yfinance 的 sector 對不起來。

2.2 同一個指數,兩檔 ETF 差 4.5 個百分點

這一組才是真正的同類比較:0050006208 追蹤同一個臺灣 50 指數,一年差 4.5 個百分點。

追蹤誤差是那五項 ETF 特有指標裡唯一算得出來的,而它的實作只有三行,重點在那個 guard:

def tracking_error(etf_closes, index_closes) -> float | None:
    """追蹤誤差:同一段期間 ETF 與指數的報酬差(百分點)。

    兩條序列長度不同就回 None,不猜怎麼對齊!
    錯開一天的對齊會安靜地產生一個看起來合理的錯數字,比沒有數字糟。
    """
    e = _clean(etf_closes)
    i = _clean(index_closes)
    if len(e) != len(i) or len(e) < 2 or e[0] == 0 or i[0] == 0:
        return None
    return ((e[-1] / e[0]) - (i[-1] / i[0])) * 100.0

差在哪,只列得出候選,給不了比例:內扣費用、追蹤方式與再平衡時點、配息與除息的處理、折溢價、規模與流動性。

這五項裡,這條管線只算得出追蹤誤差。其餘四項需要淨值、公開說明書的費用率、成分股異動公告、除息公告,都不在這條管線的來源裡。所以程式在那四格回的不是估計值:

_ETF_GAPS = ("內扣費用", "折溢價", "成分股調整", "配息與除息")


def etf_data_gaps(symbol: str) -> dict[str, str]:
    """這個標的有哪些 ETF 特有指標算不出來。指數沒有這些項目,回空 dict。"""
    if symbol.startswith("^"):
        return {}
    return {name: INSUFFICIENT for name in _ETF_GAPS}

這跟 SPCX 中長期評分的處置一致:算不出來就說算不出來。

2.3 這個數字每天都在動

變的是「近一年」這個視窗,它每天往前滾一天,掉出一天舊的、補進一天新的。掉出去的那天如果漲幅特別大,整條的年報酬就會位移。


3. 五個維度是怎麼算出來的

技術面這套只用五個維度,而且刻意都是位置幅度類的:

維度 看什麼 警示條件
MA 排列 MA5 / MA20 / MA60 的相對位置 空頭排列(5 < 20 < 60)
RSI(14) 相對強弱 ≥ 70 或 ≤ 30,兩端都算
布林通道 收盤在通道裡的位置 收在帶外(> 1 或 < 0)
年化波動度 近 20 日相對長期的倍數 超過 1.5 倍
距 52 週高點回撤 離高點多遠 低於 -10%

表格是結論,底下這幾節是算式。指標模組是純函式、零 I/O、零 UI 相依,所以每一個都可以拿一段手算過的資料直接對答案。

3.1 缺值紀律:回 None,不回 NaN

這一條是整個模組的地基,比任何一個指標都先決定。

def _window(series, end: int, size: int) -> list[float] | None:
    """取 series[end-size+1 : end+1],任何一格不可用就回 None。"""
    start = end + 1 - size
    if start < 0:
        return None
    out: list[float] = []
    for v in series[start : end + 1]:
        if v is None:
            return None
        f = float(v)
        if math.isnan(f):
            return None
        out.append(f)
    return out

窗口裡只要有一格是 NoneNaN,整格回 None

差別在下游。NaN 會安靜地傳染:它參與任何算術的結果還是 NaN,一路傳到平均、傳到分數,最後在頁面上變成一個 nan 字樣,或者更糟,被某一層 fillna(0) 接住,變成一個看起來正常的 0。None 不會,它在顯示層明確變成「—」,在評分層則會被算成「這個維度算不出來」。

3.2 RSI 為什麼是遞迴的

RSI 常被講成「N 日內漲幅平均除以跌幅平均」,但 Wilder 的原始定義不是單純的移動平均,是遞迴平滑:第一格用前 14 根變動的簡單平均起手,之後每一格都是舊值權重 13、新值權重 1。

    avg_gain = sum(gains[:period]) / period
    avg_loss = sum(losses[:period]) / period
    out[period] = _rsi_from(avg_gain, avg_loss)

    for i in range(period, len(gains)):
        avg_gain = (avg_gain * (period - 1) + gains[i]) / period
        avg_loss = (avg_loss * (period - 1) + losses[i]) / period
        out[i + 1] = _rsi_from(avg_gain, avg_loss)

這個差別是會被看見的:用簡單移動平均算出來的「RSI」跟 Wilder 版本在同一段資料上可以差好幾點,而兩者都會被叫做 RSI(14)。要嘛照原始定義寫,要嘛就得在文件上講清楚自己算的是別的東西。

另外那個邊界:跌幅平均為零的時候不能直接除。

def _rsi_from(avg_gain: float, avg_loss: float) -> float:
    if avg_loss == 0:
        return 100.0 if avg_gain > 0 else 50.0
    if avg_gain == 0:
        return 0.0
    return 100.0 - 100.0 / (1.0 + avg_gain / avg_loss)

連續十四天沒有一天下跌,RSI 是 100。而十四天完全沒動(漲跌都是零),回的是 50 不是 100,因為沒有動靜的東西,不該被讀成極度強勢。

3.3 RSI 兩端都算警示

跟昨天景氣對策信號那格同一個處理。

    if r >= RSI_HIGH:
        return True, f"RSI(14) {r:.1f} ≥ {RSI_HIGH:.0f}"
    if r <= RSI_LOW:
        return True, f"RSI(14) {r:.1f} ≤ {RSI_LOW:.0f}"
    return False, f"RSI(14) {r:.1f}"

把 RSI 30 記成「好」、70 記成「壞」(或反過來),等於偷偷塞進一個方向判斷,而這套東西的定位是量測不是指示,所以兩端都只記「落在不尋常的地方」,不說那是好是壞。

代價是分數會變鈍:一檔剛從超賣反彈的標的,跟一檔衝到超買的標的,可能拿到一樣的分數。

3.4 布林通道:位置、帶寬,跟那個 ddof

布林通道是中線加減 N 個標準差,N 慣例取 2。實作上有一個容易被略過的決定,標準差要用母體(除以 n)還是樣本(除以 n−1):

        mean = mid[i]
        var = sum((x - mean) ** 2 for x in w) / period
        sd = math.sqrt(var)
        upper.append(mean + num_std * sd)
        lower.append(mean - num_std * sd)

除的是 period,也就是 ddof=0,母體標準差,理由是這裡的 20 根不是從某個母體抽出來的樣本,它就是要描述的那 20 根本身,這也跟多數看盤軟體的畫法一致;改用樣本標準差的話通道會稍微寬一點,帶外的判定就會變鬆。

然後把「收盤在通道裡的位置」正規化成 0 到 1:

    width = upper[-1] - lower[-1]
    if width == 0:
        return 0.5
    return (float(last) - lower[-1]) / width

貼下軌是 0、貼上軌是 1,所以 > 1 才叫收在帶外。用比例而不是絕對價差的好處是:這個數字在加權指數(四萬多點)跟 0050(一百多元)上是同一種判斷方式。

模組裡還算了帶寬(上下軌距離除以中線),但它沒有進評分。帶寬量的是「最近波動的收放」,而那件事已經被波動度那個維度量過一次了,兩個放進同一組等權的分數會讓波動被記兩次,留著它是因為圖上用得到。

3.5 波動度:對數報酬、母體標準差、乘上根號 252

    rets = [math.log(clean[i] / clean[i - 1]) for i in range(1, len(clean))]
    mean = sum(rets) / len(rets)
    var = sum((r - mean) ** 2 for r in rets) / len(rets)
    return math.sqrt(var) * math.sqrt(periods_per_year)

三個決定各講一句。用對數報酬不用簡單報酬,因為對數報酬可加:兩天的對數報酬相加就是兩天的總對數報酬,簡單報酬不行,而年化本質上就是在把日報酬加起來。乘 sqrt(252) 是波動隨時間以根號成長的標準假設,252 是美股一年的交易日數。缺值那一段報酬直接跳過不補值:把缺的那天補成前一天會製造一個假的零報酬,而零報酬會壓低波動度。

那個警示門檻刻意是倍數不是絕對值:近 20 日相對長期水準超過 1.5 倍才警示。台股與美股的常態波動度本來就不同,寫死一個「年化 30% 以上算高」等於偷偷假設兩個市場一樣。用標的自己的長期水準當分母,這個標準對兩邊才會都成立。

3.6 那些「常見的」為什麼沒收

技術指標還有一大票,這裡一次交代為什麼停在五個(這邊是 Claude 給出的理由):

  • KD(隨機指標):量的是收盤在近期高低區間裡的位置。它跟 RSI 高度重疊,兩個都是「相對強弱」的變形,一起放進等權的分數等於把同一件事投兩票。
  • MACD:快慢均線的差離,跟 MA 排列那個維度重疊。而且 MACD 的訊號多半要看「黃金交叉/死亡交叉」,那已經是事件判定,離「現在是什麼狀態」比較遠。
  • ATR(真實波幅均值):量絕對波動幅度,會遇到跟波動度一樣的跨市場問題,而波動度那格已經用倍數處理掉了。
  • 型態辨識(頭肩頂、雙底、旗形):「這像不像頭肩頂」有很大的主觀成分,寫成程式要先定義肩要多高、頸線容許多少偏差、成交量要不要配合,每一個閾值都是憑感覺定的,而定完之後函式回的是一個 True / False。把一連串主觀判斷壓縮成一個布林值,下游就完全看不出那個 True 有多勉強。五個位置與幅度類的維度至少可以直接印出「RSI 62.7」讓人自己判斷。

前三個不是因為沒用被刪掉,是因為跟已經收進來的維度共線。五個等權的維度裡混進兩個量同一件事的,等於偷偷加權而沒有說。


4. 那個標準憑什麼是這樣

分數的算法跟總經那層一樣簡單:未警示維度的比例 × 100

    verdicts = {k: fn(closes) for k, fn in TECHNICAL.items()}
    usable = {k: v for k, v in verdicts.items() if v[1] != INSUFFICIENT}
    if not usable:
        return IndexScore(symbol=symbol, score=None, valid=0)

    hit = [k for k, (alert, _) in usable.items() if alert]
    return IndexScore(
        symbol=symbol,
        score=100.0 * (1.0 - len(hit) / len(usable)),
        valid=len(usable),
        ...
    )

兩個地方值得指出來。第一,算不出來的維度不計入分母,所以回傳裡有一個 valid 欄位記著這次是幾個維度算出來的:80 分在五個維度跟在三個維度底下是兩件事,不把 valid 一起帶出去就分不出來。第二,每個維度的 subscore 只有 0.0 或 1.0,不看警示得多嚴重。RSI 71 跟 RSI 95 同樣記一次警示。

^TWII 最近五個交易日(2026-09-09 跑):

日期 分數 警示
2026-09-03 100.0
2026-09-04 100.0
2026-09-07 80.0 布林通道
2026-09-08 100.0
2026-09-09 100.0

簡單平均 96.0、加權平均 96.0(權重 D-4 10%、D-3 15%、D-2 20%、D-1 25%、D-0 30%)。

最新一日的分項長這樣:

ma_stack    多頭排列 MA5 46,804.84 > MA20 45,960.65 > MA60 45,196.84
rsi         RSI(14) 61.3
bollinger   通道內位置 0.90
volatility  近 20 日年化波動 16.4%,長期 27.1%(以 243 個交易日代替 52 週,已降級),0.60 倍
drawdown    距 52 週高點 -1.2%(以 243 個交易日代替 52 週,已降級)

每個維度回的不只是布林值,是一個 (是否警示, 理由字串) 的組合,而理由字串裡帶著算出來的實際數值。

4.1 門檻是硬定出來的

每一個門檻都是 Claude 選的,沒有一個是最佳化出來的。 我看過並同意,但那不改變它們的來源:70/30 是 Wilder 的慣例值、1.5 倍是「明顯比平常吵」的直覺、−10% 是常見的「修正」門檻、五個維度等權是暫時的方式。

跟「拿歷史資料回歸、按係數配權重」比,這個做法的誠實度差在哪?差在它沒有假裝自己有依據。回歸出來的權重看起來科學,但它是對過去那段特定期間最佳化的結果,換一段期間就變(其實兩種都不可靠)。

4.2 「已降級」那四個字

分項裡那句「以 242 個交易日代替 52 週,已降級」是實作時撞到的。

台股一年只有 243 到 244 根日線,美股 252 根。回看視窗原本寫死成 250 根,結果台股的波動度與回撤這兩個維度永遠算不出來。分數照樣算得出來(分母從 5 變成 3),看起來毫無異狀,但台股的分數與美股的分數量的根本不是同一組東西。(之後可能會再調整這部分的實作)

def _lookback(v: list[float]) -> tuple[list[float], str] | None:
    """取回看視窗,並回一段說明它是不是降級的。不足底線回 None。"""
    if len(v) < LOOKBACK_MIN:
        return None
    window = v[-LOOKBACK_FULL:]
    if len(window) < LOOKBACK_FULL:
        return window, f"(以 {len(window)} 個交易日代替 52 週,{DEGRADED})"
    return window, ""

三種結果:足額就是 52 週;不足額降級,而且理由字串自己說出降級了;低於底線回 None,也就是資料不足,不硬算。底線設 200 根,因為它擋得住只有 59 根的 SPCX,又放得過整年 243 根的台股。

降級有底線,不是無限退讓。 一條只有二十根日線的序列硬算「52 週高點」,算得出數字,但那個數字沒有意義。


5. 籌碼面:三個來源、三種單位、兩種「沒有」

5.1 市場級的三個讀數

日期 融資餘額(張) 融券(張) 外資台指期淨未平倉(口) P/C 未平倉比(%) 三大法人買賣超(股)
2026-09-03 8,902,986 222,843 -81,575 77.2 -658,913,818
2026-09-04 8,919,251 219,160 -82,389 99.8 +577,637,662
2026-09-07 9,073,251 211,213 -79,399 111.7 +795,049,420
2026-09-08 9,048,662 208,614 -82,262 105.2 -305,444,935
2026-09-09 9,070,714 203,610 -81,066 96.3 -37,449,937

單位在這張表裡有三種:融資融券是「張」、台指期未平倉是「口」、三大法人買賣超是「股」。三者互不換算,一口台指期不是一千股的任何東西。所以程式裡每個欄位名都自己帶單位字尾(_lots / _contracts / _shares),不給任何人「順手乘 1000」的機會。

5.2 空 CSV 不是零

三大法人買賣超(T86)是盤後資料,當天下午之前抓會拿到空 CSV。而證交所的「空」有兩種長相:真的空字串,或者一句中文「沒有符合條件的資料」。期交所的「空」又是第三種:只有表頭、沒有任何資料列。

三種長相,同一個處置:

    lines = text.splitlines()
    start = next((i for i, ln in enumerate(lines) if "證券代號" in ln), None)
    if start is None:
        raise NotPublishedYet(
            f"TWSE T86 {date}: 空 CSV — 這天的盤後資料還沒公布"
            f"(或不是交易日)。這不等於買賣超是 0。"
        )

NotPublishedYet 是自己定義的例外型別,而不是回 None 或回 0。理由是這個區別必須逼呼叫端處理:「還沒公布」跟「這天成交 0」在下游是兩件完全不同的事,前者要保留前一天的值並標 stale,後者是一個真的讀數。回 0 的話兩者長得一模一樣,然後平均值就會被一堆假的零拉下去。

順帶一提,非交易日與「盤後還沒公布」在回應上長得也一樣,程式分不出來,也刻意不分。兩者的處置相同,而要分就得自己維護一份交易日曆,那是另一個會過期的東西。

還有兩個實作細節。證交所的 CSV 前面有幾行標題列,真正的表頭是含「證券代號」的那一列,所以不能直接從第一行開始讀。編碼要用 big5hkscs,不是 UTF-8 也不是 big5,後者遇到某些字會直接炸掉。

5.3 融資融券:今日餘額是暫定的

這一節是今天最意外的一個發現,而且它不是我的判斷,是證交所自己在報表備註裡寫的:

請以「前日餘額」為準,而以「今日餘額」為輔助參考資料。

原因是各授信機構在成交次日仍繼續調帳。所以今天抓到的「今日餘額」,明天會再變;要拿到某一天的定稿數字,得等隔天回來抓那份報表上的「前日餘額」。

問題是這件事寫在網頁備註裡,程式不會自己知道。三個月後看到 margin_today_lots 這個欄位名,沒有人會記得它是暫定值。所以處置不是加一行註解,是把它寫進型別:

    date: dt.date
    margin_prev_lots: float | None      # 融資・前日餘額(定稿)
    margin_today_lots: float | None     # 融資・今日餘額(暫定)

    @property
    def provisional_for(self) -> dt.date:
        """「今日餘額」屬於的日期 —— 就是當天,但值是暫定的。"""
        return self.date

    @property
    def final_for(self) -> dt.date | None:
        """「前日餘額」屬於的日期 —— **前一個交易日**,不是當天。"""
        return self.date - dt.timedelta(days=1) if self.date else None

關鍵是這兩個 property 把兩個數字掛在不同的日期上,而不是擺成兩個平行欄位讓呼叫端自己記得誰是誰。平行欄位的版本一樣跑得動,但它把「哪個才算數」這個判斷推給每一個使用它的人,而那種判斷遲早會有一次做錯。

所以上面那張表最新一天(9/9)的融資融券兩格,此刻仍是「今日餘額」的暫定值:融資融券約 21:30 才公布,18:05 那班排程根本不去問它,22:30 有另一班補;而就算補到了,那個數字也還要等隔天才定稿。

5.4 台指期與選擇權那兩個數字

表上那兩欄是期交所來的,各講一下它們是什麼。

外資台指期淨未平倉:外資在台指期上「未平倉的多單減未平倉的空單」,單位是口。它跟買賣超不同:買賣超是當天的流量,未平倉是累積的部位。負數代表淨空方。

P/C 未平倉比:選擇權的賣權(Put)未平倉量除以買權(Call)未平倉量,用百分比表示。這個比值常被當成情緒指標,但方向要小心:高的 P/C 比可以讀成避險需求增加,也可以讀成反向指標,兩種講法都有人用,所以這套東西只記數值與兩端門檻,不記方向。

期交所同時提供成交量比未平倉比兩個版本,這裡取的是未平倉比:

        out.append({
            "date": d,
            "put_volume": _num(r[1]),
            "call_volume": _num(r[2]),
            "pc_volume_ratio_pct": _num(r[3]),
            "put_oi": _num(r[4]),
            "call_oi": _num(r[5]),
            "pc_oi_ratio_pct": _num(r[6]),
        })

成交量比每天都在跳,反映的是當天的交易活動;未平倉比是存量,變化比較慢,比較接近「現在市場的部位長什麼樣」。兩個都解析出來存著,評分只用後者。

實作上的三個坑:期交所要 POST 不是 GET;編碼一樣是 big5hkscs;而三大法人的身份別用字跟證交所不一樣:期交所是「自營商、投信、外資及陸資」,證交所 T86 那邊的欄位名則是「外陸資買賣超股數(不含外資自營商)」這一類。兩邊都叫三大法人,欄位名對不起來,用同一組字串去比對會默默抓到空值。

還有一件小事:期交所給的資料是新到舊排序,解析後照原樣回傳不重排。要最新一筆就取 [0],要按日期排的自己排。在 adapter 層替呼叫端決定排序,是另一種會被忘記的隱含假設。

5.5 判定看方向,不看水位

外資台指期一路是淨空(−79,399 到 −82,389 口)。程式對這個狀態的判定是不警示

原因跟昨天央行那格一樣:拿「值」去比會永遠亮燈。一直淨空是狀態不是事件,天天記一次警示,燈就沒有意義了。所以判定看的是方向翻轉:由淨多翻淨空,或反過來,那才是事件。

融資也一樣,看的是五日變化率不是餘額水位。餘額多少張是水位,墊高多快才是訊號。

5.6 同一個指標,換個標的就不能同樣讀

這一節是我覺得整天最值得記的一條。

同一個「三大法人買賣超」,套在三種標的上要讀成三件事:加權指數上是整個市場的資金流向;個股上是法人對這家公司的態度;而ETF 上多半是套利與申贖,不是「看好這個籃子」。

第三種最容易被誤讀。看到「投信大買 0050」就解讀成「法人看好」,很可能只是在做現貨與期貨之間的價差,或是應付申贖需求。ETF 的申購買回機制會讓造市商被動地買賣成分股,那跟看法無關。

所以但書不是寫在文件裡,是型別強制帶著走的:

@dataclass(frozen=True, slots=True)
class ChipReading:
    applies: bool
    caveat: str


def reading(symbol: str) -> ChipReading:
    """這個標的能不能套市場級籌碼面,以及要附上什麼但書。"""
    if not _is_tw(symbol):
        return ChipReading(applies=False, caveat=(
            f"{INSUFFICIENT} — 三大法人、融資融券、台指期都是台股的資料,"
            f"套到美股標的上沒有意義"))
    if _is_index(symbol):
        return ChipReading(applies=True, caveat=_INDEX_CAVEAT)
    return ChipReading(applies=True, caveat=_ETF_CAVEAT)

caveat 是必填欄位,沒有預設值,所以拿得到籌碼面讀數就一定同時拿到那句但書。美股那三檔則 applies=False:三大法人、融資融券、台指期都是台股的資料,套到美股標的上沒有意義。

5.7 融資維持率:沒有就是沒有

原本的規劃裡有「融資融券餘額與維持率」。餘額有,維持率沒有。

證交所的 MI_MARGN 只給餘額,市場級的維持率找不到公開來源。所以那一格現在記的是「無公開來源」,不是從餘額硬推一個

推是推得出來的:拿融資金額除以某個市值基準就有一個數字。但那個數字沒有任何來源背書。


6. 為什麼刻意不做買賣建議

0050SPY 是具名的可交易標的。提供它們的買賣建議、短中長線建議,落在投顧業務的範圍。頁面加了鎖也不改變這件事。

所以這條線畫在內容本身,不畫在鎖上:可以有評分數字、分項數值、指標圖、機制說明與侷限;不可以有買進/賣出/加碼/減碼、短中長線建議欄位、任何把分數翻譯成動作的句子。

而且這條線不靠自律。輸出層有一支 lint,掃描產出的 HTML,命中行動字眼就讓發布失敗。評分那個 dataclass 也刻意沒有 advice 欄位,想加都沒有地方放。


7. 五日評分的變化與平滑化

五個交易日只有五個點。這一節的每個數字都是定性觀察,不是統計證據。

加權用的是 D-4 10%、D-3 15%、D-2 20%、D-1 25%、D-0 30%,三個層級(總經、大盤、個股)共用同一組。

現在的視窗是 9/3 到 9/9,簡單平均與加權平均都是 96.0,因為分數只有 80 和 100 兩種值,而且分布剛好讓兩種平均撞在一起。這不是巧合也不是設計,是點太少的必然結果。


小結

今天最想留下的是第 2 節那個對比:台股那組差 13.6 個百分點、美股那組差 0.6,而它們在表格上長得一模一樣。差距大不代表誰比較好,它代表那兩個東西根本不是同一個籃子

技術面那五個維度,寫完之後最有感的反而不是算式,是每一個算式旁邊那個「不算」的分支:窗口有缺值就回 None、序列不夠長就降級並說出來、降級到底線以下就承認資料不足、兩條序列長度對不上就不猜對齊。這些分支加起來的行數,比指標本身還多。

籌碼面那一段則是另一種教訓。三個來源、三種單位、三種「空」的長相、兩套對不起來的三大法人欄位名,還有一個寫在網頁備註裡、程式永遠不會自己知道的「今日餘額是暫定值」。這些沒有一項是演算法問題,全部都是「資料到底在講什麼」的問題。

它是一支氣壓計,不是一支羅盤。 它說現在幾百帕,不說要不要帶傘。

參考資料

資料來源

其他參考


註一:文中所有價格、報酬與評分皆為 2026-09-09 單次執行的結果,屬於定性觀察,不是統計證據。近一年報酬使用滾動視窗,每天都會變動,撰稿當下的數字與本文可能不同。

註二:0050 追蹤的是臺灣 50 指數,不是加權指數。以 ^TWII 作為對照是一個明確的近似,兩者成分不同。

註三:ETF 的內扣費用、折溢價、成分股調整、配息與除息四項,本文的資料來源算不出來,程式回「資料不足」而非估計值。四項各自對 4.5 個百分點差距的貢獻比例待補

註四:融資融券的「今日餘額」為暫定值、「前日餘額」才是定稿,此為證交所報表備註所載。本文表格中的數值取自各日抓取當下的結果,未逐日回頭以次日的「前日餘額」校正,屬於已知的近似。

註五:融資融券的市場級「維持率」找不到公開來源,本文未提供,待確認

註六:評分的五個維度、各自門檻與等權設計,都是作者主觀選定的未經最佳化的起點;本文已列出每一項的理由與它量不到的東西。KD、MACD、ATR 與型態辨識未納入,理由是共線或無法寫出可辯護的判定規則,不代表這些指標無效

註七:五個交易日只有五個點,平均、加權平均與平滑化在這個尺度上不具統計意義


重要說明:本文所有數字與敘述僅為個人技術實作紀錄,不構成投資建議


上一篇
Day 25|當 Claude 開始幫我看股市:24 個總經指標,卻先敗在「資料到底是哪一天的?」
下一篇
Day 27|從能跑到能發布:用測試守住架構,打造可部署的股票分析工具
系列文
三個介面,一套工作流?30 天 Claude 跨領域實戰:從 claude.ai、Claude Desktop 到 Claude Code30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言